perf: azure deployment optimizations

This commit is contained in:
2026-04-15 16:25:31 -06:00
parent e87bb7bebf
commit f50928fef1
12 changed files with 177 additions and 29 deletions

View File

@@ -9,21 +9,53 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs: jobs:
build-and-deploy: build-backend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: write packages: write
id-token: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Set image names (lowercase) - name: Set image name (lowercase)
run: | run: |
OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
echo "BACKEND_IMAGE=ghcr.io/${OWNER}/petshop-backend" >> $GITHUB_ENV echo "BACKEND_IMAGE=ghcr.io/${OWNER}/petshop-backend" >> $GITHUB_ENV
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3.3.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push backend image
uses: docker/build-push-action@v6
with:
context: ./backend
push: true
tags: ${{ env.BACKEND_IMAGE }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
build-frontend:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
- name: Set image name (lowercase)
run: |
OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
echo "FRONTEND_IMAGE=ghcr.io/${OWNER}/petshop-web" >> $GITHUB_ENV echo "FRONTEND_IMAGE=ghcr.io/${OWNER}/petshop-web" >> $GITHUB_ENV
- name: Log in to GitHub Container Registry - name: Log in to GitHub Container Registry
@@ -33,12 +65,8 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push backend image - name: Set up Docker Buildx
uses: docker/build-push-action@v6 uses: docker/setup-buildx-action@v3
with:
context: ./backend
push: true
tags: ${{ env.BACKEND_IMAGE }}:latest
- name: Build and push frontend image - name: Build and push frontend image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
@@ -48,6 +76,22 @@ jobs:
tags: ${{ env.FRONTEND_IMAGE }}:latest tags: ${{ env.FRONTEND_IMAGE }}:latest
build-args: | build-args: |
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }} NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
runs-on: ubuntu-latest
needs: [build-backend, build-frontend]
permissions:
contents: read
id-token: write
steps:
- name: Set image names (lowercase)
run: |
OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
echo "BACKEND_IMAGE=ghcr.io/${OWNER}/petshop-backend" >> $GITHUB_ENV
echo "FRONTEND_IMAGE=ghcr.io/${OWNER}/petshop-web" >> $GITHUB_ENV
- name: Log in to Azure - name: Log in to Azure
uses: azure/login@v2 uses: azure/login@v2

View File

@@ -10,4 +10,4 @@ FROM eclipse-temurin:25-jre
WORKDIR /app WORKDIR /app
COPY --from=build /app/target/*.jar app.jar COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["java","-jar","app.jar"] ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-XX:+UseG1GC", "-jar", "app.jar"]

View File

@@ -38,6 +38,16 @@
<artifactId>spring-boot-starter-security</artifactId> <artifactId>spring-boot-starter-security</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId> <artifactId>spring-boot-starter-validation</artifactId>

View File

@@ -4,10 +4,12 @@ import com.petshop.backend.config.FlywayContextInitializer;
import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.web.config.EnableSpringDataWebSupport; import org.springframework.data.web.config.EnableSpringDataWebSupport;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableScheduling @EnableScheduling
@EnableAsync
@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO) @EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
public class BackendApplication { public class BackendApplication {
public static void main(String[] args) { public static void main(String[] args) {

View File

@@ -0,0 +1,24 @@
package com.petshop.backend.config;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.TimeUnit;
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager("userAuthCache");
manager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(60, TimeUnit.SECONDS)
.maximumSize(1000));
return manager;
}
}

View File

@@ -15,6 +15,7 @@ import com.petshop.backend.entity.StoreLocation;
import com.petshop.backend.entity.User; import com.petshop.backend.entity.User;
import com.petshop.backend.repository.UserRepository; import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.security.JwtUtil; import com.petshop.backend.security.JwtUtil;
import com.petshop.backend.security.UserAuthCacheService;
import com.petshop.backend.service.ActivityLogService; import com.petshop.backend.service.ActivityLogService;
import com.petshop.backend.service.AvatarStorageService; import com.petshop.backend.service.AvatarStorageService;
import com.petshop.backend.service.EmailService; import com.petshop.backend.service.EmailService;
@@ -58,8 +59,9 @@ public class AuthController {
private final ActivityLogService activityLogService; private final ActivityLogService activityLogService;
private final PasswordResetService passwordResetService; private final PasswordResetService passwordResetService;
private final EmailService emailService; private final EmailService emailService;
private final UserAuthCacheService userAuthCacheService;
public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService, PasswordResetService passwordResetService, EmailService emailService) { public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService, PasswordResetService passwordResetService, EmailService emailService, UserAuthCacheService userAuthCacheService) {
this.authenticationManager = authenticationManager; this.authenticationManager = authenticationManager;
this.userRepository = userRepository; this.userRepository = userRepository;
this.jwtUtil = jwtUtil; this.jwtUtil = jwtUtil;
@@ -68,6 +70,7 @@ public class AuthController {
this.activityLogService = activityLogService; this.activityLogService = activityLogService;
this.passwordResetService = passwordResetService; this.passwordResetService = passwordResetService;
this.emailService = emailService; this.emailService = emailService;
this.userAuthCacheService = userAuthCacheService;
} }
@PostMapping("/register") @PostMapping("/register")
@@ -263,6 +266,7 @@ public class AuthController {
error.put("message", "Username, email, or phone already exists"); error.put("message", "Username, email, or phone already exists");
return ResponseEntity.status(HttpStatus.CONFLICT).body(error); return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
} }
userAuthCacheService.evict(updatedUser.getId());
return ResponseEntity.ok(toUserInfoResponse(updatedUser)); return ResponseEntity.ok(toUserInfoResponse(updatedUser));
} }

View File

@@ -2,7 +2,6 @@ package com.petshop.backend.security;
import com.petshop.backend.entity.User; import com.petshop.backend.entity.User;
import com.petshop.backend.exception.ApiErrorResponder; import com.petshop.backend.exception.ApiErrorResponder;
import com.petshop.backend.repository.UserRepository;
import io.jsonwebtoken.JwtException; import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
@@ -16,16 +15,18 @@ import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.Date;
@Component @Component
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil; private final JwtUtil jwtUtil;
private final UserRepository userRepository; private final UserAuthCacheService userAuthCacheService;
private final ApiErrorResponder apiErrorResponder; private final ApiErrorResponder apiErrorResponder;
public JwtAuthenticationFilter(JwtUtil jwtUtil, UserRepository userRepository, ApiErrorResponder apiErrorResponder) { public JwtAuthenticationFilter(JwtUtil jwtUtil, UserAuthCacheService userAuthCacheService, ApiErrorResponder apiErrorResponder) {
this.jwtUtil = jwtUtil; this.jwtUtil = jwtUtil;
this.userRepository = userRepository; this.userAuthCacheService = userAuthCacheService;
this.apiErrorResponder = apiErrorResponder; this.apiErrorResponder = apiErrorResponder;
} }
@@ -44,30 +45,44 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
jwt = authHeader.substring(7); jwt = authHeader.substring(7);
Long userId; Long userId;
String username;
String roleStr;
Integer jwtTokenVersion;
try { try {
userId = jwtUtil.extractUserId(jwt); userId = jwtUtil.extractUserId(jwt);
username = jwtUtil.extractUsername(jwt);
roleStr = jwtUtil.extractRole(jwt);
jwtTokenVersion = jwtUtil.extractTokenVersion(jwt);
} catch (JwtException | IllegalArgumentException ex) { } catch (JwtException | IllegalArgumentException ex) {
writeUnauthorized(request, response, "Invalid or expired token", ex); writeUnauthorized(request, response, "Invalid or expired token", ex);
return; return;
} }
if (userId != null && SecurityContextHolder.getContext().getAuthentication() == null) { if (userId != null && SecurityContextHolder.getContext().getAuthentication() == null) {
User user = userRepository.findById(userId).orElse(null); if (jwtUtil.extractExpiration(jwt).before(new Date())) {
if (user == null || user.getActive() == null || !user.getActive()) {
writeUnauthorized(request, response, "User account is inactive", null);
return;
}
if (!jwtUtil.validateToken(jwt, user)) {
writeUnauthorized(request, response, "Invalid or expired token", null); writeUnauthorized(request, response, "Invalid or expired token", null);
return; return;
} }
AppPrincipal principal = new AppPrincipal( UserAuthCacheService.UserAuthData authData = userAuthCacheService.loadAuthData(userId);
user.getId(), if (authData == null || !Boolean.TRUE.equals(authData.active())) {
user.getUsername(), writeUnauthorized(request, response, "User account is inactive", null);
user.getRole(), return;
user.getTokenVersion() }
); if (!authData.tokenVersion().equals(jwtTokenVersion)) {
writeUnauthorized(request, response, "Invalid or expired token", null);
return;
}
User.Role role;
try {
role = User.Role.valueOf(roleStr);
} catch (IllegalArgumentException ex) {
writeUnauthorized(request, response, "Invalid or expired token", ex);
return;
}
AppPrincipal principal = new AppPrincipal(userId, username, role, jwtTokenVersion);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
principal, principal,
null, null,

View File

@@ -0,0 +1,29 @@
package com.petshop.backend.security;
import com.petshop.backend.repository.UserRepository;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
public class UserAuthCacheService {
private final UserRepository userRepository;
public UserAuthCacheService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public record UserAuthData(Boolean active, Integer tokenVersion) {}
@Cacheable(value = "userAuthCache", key = "#userId")
public UserAuthData loadAuthData(Long userId) {
return userRepository.findById(userId)
.map(u -> new UserAuthData(u.getActive(), u.getTokenVersion()))
.orElse(null);
}
@CacheEvict(value = "userAuthCache", key = "#userId")
public void evict(Long userId) {
}
}

View File

@@ -5,6 +5,7 @@ import com.petshop.backend.entity.User;
import com.petshop.backend.repository.UserRepository; import com.petshop.backend.repository.UserRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
@@ -18,6 +19,7 @@ public class ActivityLogService {
this.userRepository = userRepository; this.userRepository = userRepository;
} }
@Async
public void record(Long userId, String activity) { public void record(Long userId, String activity) {
if (userId == null || activity == null || activity.isBlank()) { if (userId == null || activity == null || activity.isBlank()) {
return; return;
@@ -36,6 +38,7 @@ public class ActivityLogService {
} }
} }
@Async
public void record(User user, String activity) { public void record(User user, String activity) {
if (user == null) { if (user == null) {
return; return;

View File

@@ -7,6 +7,7 @@ import com.petshop.backend.entity.User;
import com.petshop.backend.exception.BusinessException; import com.petshop.backend.exception.BusinessException;
import com.petshop.backend.repository.PasswordResetTokenRepository; import com.petshop.backend.repository.PasswordResetTokenRepository;
import com.petshop.backend.repository.UserRepository; import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.security.UserAuthCacheService;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -29,16 +30,19 @@ public class PasswordResetService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final EmailService emailService; private final EmailService emailService;
private final UserAuthCacheService userAuthCacheService;
private final SecureRandom secureRandom = new SecureRandom(); private final SecureRandom secureRandom = new SecureRandom();
public PasswordResetService(PasswordResetTokenRepository passwordResetTokenRepository, public PasswordResetService(PasswordResetTokenRepository passwordResetTokenRepository,
UserRepository userRepository, UserRepository userRepository,
PasswordEncoder passwordEncoder, PasswordEncoder passwordEncoder,
EmailService emailService) { EmailService emailService,
UserAuthCacheService userAuthCacheService) {
this.passwordResetTokenRepository = passwordResetTokenRepository; this.passwordResetTokenRepository = passwordResetTokenRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.emailService = emailService; this.emailService = emailService;
this.userAuthCacheService = userAuthCacheService;
} }
@Transactional @Transactional
@@ -97,6 +101,7 @@ public class PasswordResetService {
user.setPassword(passwordEncoder.encode(newPassword)); user.setPassword(passwordEncoder.encode(newPassword));
user.setTokenVersion(user.getTokenVersion() + 1); user.setTokenVersion(user.getTokenVersion() + 1);
userRepository.save(user); userRepository.save(user);
userAuthCacheService.evict(user.getId());
token.setUsedAt(now); token.setUsedAt(now);
passwordResetTokenRepository.save(token); passwordResetTokenRepository.save(token);

View File

@@ -8,6 +8,7 @@ import com.petshop.backend.entity.User;
import com.petshop.backend.exception.ResourceNotFoundException; import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.repository.StoreRepository; import com.petshop.backend.repository.StoreRepository;
import com.petshop.backend.repository.UserRepository; import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.security.UserAuthCacheService;
import com.petshop.backend.util.AuthenticationHelper; import com.petshop.backend.util.AuthenticationHelper;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
@@ -33,11 +34,13 @@ public class UserService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final StoreRepository storeRepository; private final StoreRepository storeRepository;
private final UserAuthCacheService userAuthCacheService;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, StoreRepository storeRepository) { public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, StoreRepository storeRepository, UserAuthCacheService userAuthCacheService) {
this.userRepository = userRepository; this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.storeRepository = storeRepository; this.storeRepository = storeRepository;
this.userAuthCacheService = userAuthCacheService;
} }
public Page<UserResponse> getAllUsers(String query, String role, Pageable pageable) { public Page<UserResponse> getAllUsers(String query, String role, Pageable pageable) {
@@ -147,6 +150,7 @@ public class UserService {
} }
user = userRepository.save(user); user = userRepository.save(user);
userAuthCacheService.evict(user.getId());
return mapToResponse(user); return mapToResponse(user);
} }

View File

@@ -18,6 +18,10 @@ spring:
username: ${SPRING_DATASOURCE_USERNAME:petshop} username: ${SPRING_DATASOURCE_USERNAME:petshop}
password: ${SPRING_DATASOURCE_PASSWORD:petshop} password: ${SPRING_DATASOURCE_PASSWORD:petshop}
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
sql: sql:
init: init:
@@ -42,6 +46,10 @@ server:
address: ${SERVER_ADDRESS:0.0.0.0} address: ${SERVER_ADDRESS:0.0.0.0}
servlet: servlet:
context-path: / context-path: /
compression:
enabled: true
mime-types: application/json,application/javascript,text/css,text/plain
min-response-size: 1024
springdoc: springdoc:
api-docs: api-docs: