SpringBoot应用Docker卷配置问题:图片上传路径映射异常
我开发了一个允许用户上传图片到服务器的SpringBoot应用,本地非Docker环境运行正常,无需认证即可通过浏览器链接打开图片。但部署到Docker后,用户上传的图片仅存储在容器内的masterclass-photos文件夹中,预期存放图片的宿主机/var/lib/public/images文件夹为空。
如何配置Docker,使其实现与本地运行时相同的图片访问效果?
项目结构
masterclass-photos和motherforum-data文件夹由Docker创建。
日志信息
2022-11-23 12:18:31 java.net.UnknownHostException: masterclass-photos 2022-11-23 12:18:31 at java.base/sun.nio.ch.NioSocketImpl.connect(NioSocketImpl.java:567) ~[na:na] 2022-11-23 12:18:31 at java.base/java.net.Socket.connect(Socket.java:633) ~[na:na] 2022-11-23 12:18:31 at java.base/sun.net.ftp.impl.FtpClient.doConnect(FtpClient.java:1045) ~[na:na] 2022-11-23 12:18:31 at java.base/sun.net.ftp.impl.FtpClient.tryConnect(FtpClient.java:1010) ~[na:na] 2022-11-23 12:18:31 at java.base/sun.net.ftp.impl.FtpClient.connect(FtpClient.java:1102) ~[na:na] 2022-11-23 12:18:31 at java.base/sun.net.ftp.impl.FtpClient.connect(FtpClient.java:1088) ~[na:na] ... ... 大量堆栈跟踪信息 ru.rcitsakha.motherforum.filter.CustomAuthorizationFilter.doFilterInternal(CustomAuthorizationFilter.java:73) ~[classes!/:1.0-SNAPSHOT] ... ... 大量堆栈跟踪信息
相关代码与配置
CustomAuthorizationFilter类
package ru.rcitsakha.motherforum.filter; import com.auth0.jwt.JWT; import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.interfaces.DecodedJWT; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.Map; import static java.util.Arrays.stream; import static org.springframework.http.HttpHeaders.AUTHORIZATION; import static org.springframework.http.HttpStatus.FORBIDDEN; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; @Slf4j public class CustomAuthorizationFilter extends OncePerRequestFilter { private String jwtSecret; public void setJwtSecret(String jwtSecret) { this.jwtSecret = jwtSecret; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { if (request.getServletPath().equals("/api/login") || request.getServletPath().equals("/api/token/refresh/**")) { filterChain.doFilter(request, response); } else { String authorizationHeader = request.getHeader(AUTHORIZATION); if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { try { String token = authorizationHeader.substring("Bearer ".length()); Algorithm algorithm = Algorithm.HMAC256(jwtSecret.getBytes()); // 需与authenticationFilter一致 JWTVerifier verifier = JWT.require(algorithm).build(); DecodedJWT decodedJWT = verifier.verify(token); String username = decodedJWT.getSubject(); String[] roles = decodedJWT.getClaim("roles").asArray(String.class); Collection<SimpleGrantedAuthority> authorities = new ArrayList<>(); stream(roles).forEach(role -> { authorities.add(new SimpleGrantedAuthority(role)); }); UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, null, authorities); SecurityContextHolder.getContext().setAuthentication(authenticationToken); filterChain.doFilter(request, response); } catch (Exception exception) { log.error("登录错误: {}", exception.getMessage()); response.setHeader("error", exception.getMessage()); response.setStatus(FORBIDDEN.value()); Map<String, String> error = new HashMap<>(); error.put("error_message", exception.getMessage()); response.setContentType(APPLICATION_JSON_VALUE); new ObjectMapper().writeValue(response.getOutputStream(), error); } } else { filterChain.doFilter(request, response); // 日志中的第73行 } } } }
docker-compose.yml配置
services: postgres: image: 'postgres:15' container_name: 'java-postgres' env_file: - config/.envfile volumes: - ./motherforum-data:/var/lib/postgresql/data app: build: ./ container_name: 'java-app' environment: - 'PORT=8080' - 'SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/motherforumdb' ports: - 8080:8080 depends_on: - postgres volumes: - ./masterclass-photos:/var/lib/public/images
Dockerfile配置
FROM openjdk:17 ADD /target/mother-forum-1.0-SNAPSHOT.jar backend.jar ENTRYPOINT ["java", "-jar", "/backend.jar"]
资源处理器配置
package ru.rcitsakha.motherforum.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.nio.file.Path; import java.nio.file.Paths; @Configuration public class MvcConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { exposeDirectory("masterclass-photos", registry); } private void exposeDirectory(String dirName, ResourceHandlerRegistry registry) { Path uploadDir = Paths.get(dirName); String uploadPath = uploadDir.toFile().getAbsolutePath(); if (dirName.startsWith("../")) { dirName = dirName.replace("../", ""); } registry.addResourceHandler("/" + dirName + "/**") .addResourceLocations("file:/" + uploadPath + "/"); } }
Postman请求情况
该端点无需JWT认证,安全配置为http.authorizeHttpRequests().antMatchers("/api/login/**", "/token/refresh", "api/motherforum/**").permitAll();
文件系统情况
- 宿主机文件系统中未创建
masterclass-photos/3文件夹,但该文件夹在容器内已创建。
- 预期存放图片的
/var/lib/public/images文件夹为空:
Java应用容器挂载信息
"Mounts": [ { "Type": "bind", "Source": "C:\\ProdApps\\mother-forum\\masterclass-photos", "Destination": "/var/lib/public/images", "Mode": "rw", "RW": true, "Propagation": "rprivate" } ]
MasterClass类(图片路径相关)
是否需要修改getPhotosImagePath()方法?
package ru.rcitsakha.motherforum.model; import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.*; import javax.persistence.*; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; import javax.validation.constraints.Pattern; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @Entity @Table(name = "masterclass") @AllArgsConstructor @NoArgsConstructor @Getter @Setter @ToString public class MasterClass { private static final String DATE_TIME_FORMATTER = "dd.MM.yyyy HH:mm"; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; private String title; @Column(length = 2000) private String description; private String address; private String dateTime; private boolean isExpired; @ManyToMany(fetch = FetchType.LAZY, cascade = { CascadeType.PERSIST, CascadeType.MERGE }) @JoinTable( name = "masterclass_mother", joinColumns = @JoinColumn(name = "masterclass_id"), inverseJoinColumns = @JoinColumn(name = "mother_id") ) @ToString.Exclude @JsonIgnore private Set<Mother> mothers = new HashSet<>(); public void addMother(Mother mother) { this.mothers.add(mother); mother.getMasterClasses().add(this); } @Column(length = 128) @ElementCollection private List<String> photos = new ArrayList<>(); public void removeMother(long motherId) { Mother mother = this.mothers.stream() .filter(m -> m.getId() == motherId) .findFirst() .orElse(null); if (mother != null) { this.mothers.remove(mother); mother.getMasterClasses().remove(this); } } public void copy(MasterClass masterClass) { this.title = masterClass.title; this.description = masterClass.description; this.address = masterClass.address; this.dateTime = masterClass.dateTime; this.isExpired = masterClass.isExpired; } @Transient public List<String> getPhotosImagePath() { if (photos == null) { return null; } return photos.stream() .map(photoFileName -> "/masterclass-photos/" + id + "/" + photoFileName) .collect(Collectors.toList()); } }
核心问题分析
当前Docker挂载的是宿主机./masterclass-photos到容器/var/lib/public/images,但应用代码中图片上传、资源映射均基于容器内的masterclass-photos目录,两者路径不匹配,导致文件写入容器内的masterclass-photos而非挂载的宿主机目录。
步骤1:修正Docker挂载路径
修改docker-compose.yml中app服务的volumes配置,将宿主机目录挂载到容器内应用实际使用的路径:
volumes: - ./masterclass-photos:/masterclass-photos
这样容器内写入/masterclass-photos的文件会同步到宿主机的./masterclass-photos目录。
步骤2:验证资源映射配置
MvcConfig中已正确映射/masterclass-photos/**到容器内的masterclass-photos目录,无需修改。
步骤3:确认图片上传逻辑的存储路径
检查上传代码,确保文件写入路径为容器内的/masterclass-photos(相对路径或绝对路径均可),避免写入其他未挂载的目录。
步骤4:解决UnknownHostException问题
日志中的java.net.UnknownHostException: masterclass-photos是因为代码错误地将目录名当作主机名访问,检查上传逻辑中是否存在FTP或网络请求操作,替换为本地文件系统读写即可。
步骤5:开放图片访问权限
在Spring Security配置中添加图片路径的匿名访问权限:
http.authorizeHttpRequests().antMatchers("/masterclass-photos/**").permitAll();
确保浏览器可直接访问图片链接,无需认证。
内容的提问来源于stack exchange,提问作者Zakhar Borisov

